33 Tween.js 动画库集成与基础动画开发

Tween.js 动画库集成与基础动画开发

关联:索引

要解决的问题

本讲定位(与前置衔接,避免重复)

章节内容(本讲核心)

环境与先修(默认沿用 Three.js 工程)

先修要求:

本讲新增依赖(Tween.js):

npm i @tweenjs/tween.js

解释:

npm i three
npm i -D @types/three

Tween.js 可以理解成“时间驱动插值器”:

最重要的结论(背下来就能排错):

补充一条“工业化口径”(避免只追求“丝滑”):

目标:

建议落地文件路径(班级统一口径,便于排错):

src/
└─ components/
   └─ TweenBasicLab.vue

1) 可复制运行:Tween.js 最小闭环组件(Vue3 + TS)

src/components/TweenBasicLab.vue

<template>
  <!-- Three.js 的 canvas 会被插入到这个容器里 -->
  <div ref="containerRef" class="three-container"></div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { Easing, Group, Tween } from '@tweenjs/tween.js';

// 1) 容器 DOM 引用:用于挂载 renderer.domElement(canvas)
const containerRef = ref<HTMLDivElement | null>(null);

// 2) Three.js 核心对象:在 mounted 时创建,在 beforeUnmount 时释放
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;

// 3) 生命周期相关句柄:用于停止渲染循环与尺寸监听,避免“越切越卡”
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;

// 4) Tween.js 的“动作容器”:统一管理本组件里所有 Tween
//    好处:只需要一个 tweenGroup.update(time) 就能推进全部动画;
//    未来也可以做到:暂停/恢复/清空(removeAll)
const tweenGroup = new Group();

// 5) 场景中的对象:这里用一个盒子代表 AGV 小车
let agv: THREE.Mesh | null = null;

function resize() {
  const container = containerRef.value;
  if (!container || !renderer || !camera) return;

  // 容器大小(注意:容器高度为 0 时,Three.js 会表现为“黑屏/看不到”)
  const width = container.clientWidth;
  const height = container.clientHeight;
  if (width <= 0 || height <= 0) return;

  // 像素比:上限 2,避免高 DPI 设备渲染成本过高导致掉帧
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  // 让 canvas 尺寸与容器一致(包含 style 尺寸),避免画布仍停留在默认 300×150 导致“右侧看起来是空的”
  renderer.setSize(width, height);

  // 相机宽高比必须跟随容器变化,否则画面会被拉伸
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

// 渲染循环:浏览器每帧调用一次(通常 ~60fps,实际取决于设备与负载)
function animate(time: number) {
  if (!renderer || !scene || !camera) return;

  // 关键:推进 Tween(用 rAF 的 time 作为统一时间基准,避免与渲染不同步)
  tweenGroup.update(time);
  // 关键:渲染一帧
  renderer.render(scene, camera);
  // 下一帧继续
  rafId = requestAnimationFrame(animate);
}

// 业务动作:让 AGV 从当前位置移动到目标点(只演示一次)
function moveAgvOnce() {
  if (!agv) return;

  // state:Tween 驱动的数据对象(推荐做法)
  // - Tween.js 会不断修改 state.x/state.z
  // - 我们在 onUpdate 里把 state 写回到 Three.js 对象
  // - 这样可以避免 Tween 直接操作 Three.js 对象导致的耦合与副作用
  const state = { x: agv.position.x, z: agv.position.z };

  // target:目标位置(这里使用 XZ 平面移动,Y 由我们固定到 0.1)
  const target = { x: 2.5, z: 1.2 };

  // 创建一个 Tween(把它挂到 tweenGroup 里,便于统一管理)
  new Tween(state, tweenGroup)
    // to(target, durationMs):从当前 state 补间到 target,持续 1500ms
    .to(target, 1500)
    // easing:速度曲线(先加速后减速,工业场景更像电机启停)
    .easing(Easing.Quadratic.InOut)
    // delay:延迟 200ms 开始(常用于“停稳/等待上一动作结束/启动缓冲”)
    .delay(200)
    // onUpdate:每次 state 更新时,把 state 写回 Three.js 对象
    .onUpdate(() => {
      // 这里把 Y 固定为 0.1,让 AGV “贴地但不穿透网格”
      agv?.position.set(state.x, 0.1, state.z);
    })
    // start:启动(注意:Tween 不会自动运行,必须 start 且在 rAF 中 update)
    .start();
}

onMounted(() => {
  const container = containerRef.value;
  if (!container) throw new Error('Three container not found');

  // ---------- Three.js:创建场景 ----------
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  // ---------- Three.js:创建渲染器 ----------
  renderer = new THREE.WebGLRenderer({ antialias: true });
  // 颜色空间:保证材质颜色在 sRGB 空间下更符合预期(与现代 Three.js 口径一致)
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  // 把 canvas 插入到容器
  container.appendChild(renderer.domElement);

  // ---------- Three.js:创建相机 ----------
  // aspect 初始给 1,随后立刻调用 resize() 修正
  camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
  camera.position.set(4, 3, 6);
  camera.lookAt(0, 0, 0);

  // ---------- 辅助物:帮助校准坐标系与尺度 ----------
  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(10, 10));

  // ---------- 场景对象:AGV ----------
  const agvGeo = new THREE.BoxGeometry(0.8, 0.2, 0.6);
  const agvMat = new THREE.MeshStandardMaterial({ color: 0x22c55e, roughness: 0.6, metalness: 0.1 });
  agv = new THREE.Mesh(agvGeo, agvMat);
  agv.position.set(0, 0.1, 0);
  scene.add(agv);

  // ---------- 光照:StandardMaterial 需要光才能“有质感” ----------
  const ambient = new THREE.AmbientLight(0xffffff, 0.7);
  const dir = new THREE.DirectionalLight(0xffffff, 1.0);
  dir.position.set(3, 5, 2);
  scene.add(ambient, dir);

  // ---------- 工程化:自适应尺寸 ----------
  resize();
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  // ---------- 启动渲染循环 + 启动动画 ----------
  rafId = requestAnimationFrame(animate);
  moveAgvOnce();
});

onBeforeUnmount(() => {
  // 1) 停止 rAF 渲染循环
  if (rafId !== null) cancelAnimationFrame(rafId);
  // 2) 停止尺寸监听
  if (resizeObserver) resizeObserver.disconnect();

  // 3) 从场景移除对象(避免引用残留)
  if (scene && agv) scene.remove(agv);

  // 4) 释放几何体与材质(释放 GPU 资源)
  const geometry = agv?.geometry;
  if (geometry && 'dispose' in geometry) geometry.dispose();

  const material = agv?.material;
  if (Array.isArray(material)) material.forEach((m) => m.dispose());
  else material?.dispose();

  // 5) 释放渲染器
  renderer?.dispose();
  // 6) 移除 canvas(避免路由切换后 DOM 越堆越多)
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  // 7) 断开引用(帮助 GC,避免误用已释放对象)
  agv = null;
  camera = null;
  scene = null;
  renderer = null;
  resizeObserver = null;
  rafId = null;
});
</script>

<style scoped>
.three-container {
  width: 100%;
  /* 保证容器有高度,否则 clientHeight=0 导致“看似渲染失败” */
  height: 100vh;
  overflow: hidden;
}
</style>

解释(关键点逐条对照):

自检清单(本段代码运行不起来优先按顺序排):

  1. 容器高度是否为 0(.three-container { height: 100vh; } 是否存在)。
  2. animate 是否启动(是否执行 requestAnimationFrame(animate))。
  3. tweenGroup.update(time) 是否在 render 之前被调用。
  4. moveAgvOnce() 是否被调用、agv 是否非空。

在工业场景里,最常见的“基础动画三件套”对应 Three.js 的三大变换:

1) 旋转动画(示例:设备绕 Y 轴旋转)

function rotateOnce(object: THREE.Object3D) {
  // 旋转状态:Tween 驱动的数值对象(单位:弧度)
  const state = { y: object.rotation.y };
  // 目标:绕 Y 轴旋转 90°
  const target = { y: state.y + Math.PI / 2 };

  // 注意:object.rotation 是欧拉角(Euler),这里直接写 rotation.y 便于理解;
  // 工业中如果涉及复杂姿态/组合旋转,优先考虑 quaternion,避免万向节锁
  new Tween(state, tweenGroup)
    // 800ms:相对“干脆”的转动,适合按钮触发的简单旋转
    .to(target, 800)
    // Cubic.InOut:启停更柔和,更像“电机带负载”
    .easing(Easing.Cubic.InOut)
    .onUpdate(() => {
      // 每次更新都把 state 写回到对象
      object.rotation.y = state.y;
    })
    .start();
}

解释:

2) 缩放动画(示例:设备被选中时轻微“呼吸”)

function pulseScale(object: THREE.Object3D) {
  // s:统一缩放因子(1 表示原大小)
  const state = { s: 1 };

  new Tween(state, tweenGroup)
    // 先放大到 1.08(轻微“呼吸”,不要太夸张,否则像 UI 特效而不像工业提示)
    .to({ s: 1.08 }, 300)
    // Out:更快到达峰值,体现“被选中”的即时反馈
    .easing(Easing.Quadratic.Out)
    // yoyo:回弹(放大后再缩回)
    .yoyo(true)
    // repeat(1):只来回一次(放大一次 + 缩回一次)
    .repeat(1)
    .onUpdate(() => {
      // setScalar:三轴等比缩放,避免模型比例被破坏
      object.scale.setScalar(state.s);
    })
    .start();
}

解释:

两种组织方式对应两类工业需求:

工程建议(避免“回调地狱”):

本项目工坊目标:

建议落地文件路径:

src/
└─ components/
   └─ TweenIndustrialLab.vue

1) 可复制运行:工业基础动画组件(Vue3 + TS)

src/components/TweenIndustrialLab.vue

<template>
  <!-- 容器:Three.js canvas + HUD 叠加层 -->
  <div ref="containerRef" class="three-container">
    <div class="hud">
      <div class="row">Tween.js 工业动画实验</div>
      <div class="row">
        <button class="btn" @click="runSequence">序列:AGV 走路径 → 关节旋转</button>
      </div>
      <div class="row">
        <button class="btn" @click="runParallel">并行:AGV 走路径 + 关节旋转</button>
      </div>
      <!-- fps 是一个粗略指标:用于自检“动画是否明显掉帧” -->
      <div class="row">FPS(估算):{{ fps }}</div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { Easing, Group, Tween } from '@tweenjs/tween.js';

// DOM 容器引用:用于挂载 renderer.domElement
const containerRef = ref<HTMLDivElement | null>(null);
// FPS(估算):不追求精度,追求“快速发现异常”
const fps = ref(0);

// Three.js 核心对象(mounted 创建 / beforeUnmount 释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;

// 生命周期句柄(用于停止循环与监听)
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;

// Tween 容器:统一管理所有 Tween,做到可控(清空/暂停/恢复)
const tweenGroup = new Group();

// 业务对象:AGV 与机械臂(用几何体代替)
let agv: THREE.Group | null = null;
let armBase: THREE.Group | null = null;
let joint1: THREE.Group | null = null;

// 记录 Mesh 引用:用于卸载时 dispose 几何体/材质,避免 GPU 资源累积
let agvBody: THREE.Mesh | null = null;
let baseMesh: THREE.Mesh | null = null;
let link1: THREE.Mesh | null = null;

// FPS 统计:用“半秒窗口”估算一次(避免每帧计算过重)
let frameCount = 0;
let lastFpsTime = 0;

// 工业化配置:把“运动逻辑参数”集中收口,避免散落在代码里难以统一调整
// - 速度/时长钳制:保证节拍可解释、可复现
// - easing:模拟启停与负载感(而不是追求花哨)
const motionConfig = {
  // 单位约定:这里假设场景单位≈米(1 unit ≈ 1m)
  unitIsMeter: true,
  // AGV 目标速度(m/s):用它推导每段路径的时长
  agvSpeedMps: 1.2,
  // 单段移动时长的上下限(ms):防止太短“闪现”、太长“拖沓”
  agvMinSegmentMs: 450,
  agvMaxSegmentMs: 2600,
  // 到达路点后的“停稳”时间(ms):模拟设备到位的停顿,减少漂移感
  waypointDwellMs: 200,
  // 平移缓动:更像电机启停
  agvMoveEasing: Easing.Quadratic.InOut,
  // 航向(yaw)缓动:更柔和,像行驶中转向
  agvYawEasing: Easing.Cubic.InOut,
  // 关节缓动:启停柔和,像关节伺服
  jointYawEasing: Easing.Cubic.InOut,
} as const;

function resize() {
  const container = containerRef.value;
  if (!container || !renderer || !camera) return;

  const width = container.clientWidth;
  const height = container.clientHeight;
  if (width <= 0 || height <= 0) return;

  // 统一像素比上限,兼顾清晰度与性能
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(width, height);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

function animate(time: number) {
  if (!renderer || !scene || !camera) return;

  // 1) 推进所有 Tween(time 来自 rAF,是统一时间基准)
  tweenGroup.update(time);
  // 2) 渲染
  renderer.render(scene, camera);
  // 3) 下一帧
  rafId = requestAnimationFrame(animate);

  // 4) FPS 估算:用于发现“掉帧/抖动”
  frameCount += 1;
  if (lastFpsTime === 0) lastFpsTime = time;
  if (time - lastFpsTime >= 500) {
    fps.value = Math.round((frameCount * 1000) / (time - lastFpsTime));
    frameCount = 0;
    lastFpsTime = time;
  }
}

// 把“一段 Tween 动作”包装成 Promise:便于用 await 组织序列,用 Promise.all 组织并行
function tweenTo<T extends Record<string, number>>(
  state: T,
  target: Partial<T>,
  options: { durationMs: number; delayMs?: number; easing?: (k: number) => number },
  onUpdate: () => void,
) {
  return new Promise<void>((resolve) => {
    new Tween(state, tweenGroup)
      .to(target as T, options.durationMs)
      .easing(options.easing ?? Easing.Quadratic.InOut)
      .delay(options.delayMs ?? 0)
      .onUpdate(onUpdate)
      .onComplete(() => resolve())
      .start();
  });
}

// 工具:把时长钳制到合理区间(避免过短/过长)
function clampMs(ms: number, min: number, max: number) {
  return Math.min(max, Math.max(min, ms));
}

// 工业化关键:用“距离/速度”推导时长,而不是拍脑袋写死 1200ms
function computeMoveDurationMs(from: THREE.Vector3, to: THREE.Vector3) {
  const dx = to.x - from.x;
  const dz = to.z - from.z;
  // 这里用 XZ 平面的欧氏距离(假设地面运动)
  const distance = Math.hypot(dx, dz);
  // 最小速度保护:避免速度=0 导致除零
  const speed = Math.max(0.1, motionConfig.agvSpeedMps);
  return clampMs(
    (distance / speed) * 1000,
    motionConfig.agvMinSegmentMs,
    motionConfig.agvMaxSegmentMs,
  );
}

// 由路径方向估算航向角(yaw)
// - 约定:AGV 的“前进方向”指向 +Z(与 GridHelper 的直觉一致)
// - Math.atan2(dx, dz) 的写法对应“朝向目标点”的 yaw
function computeYawRad(from: THREE.Vector3, to: THREE.Vector3) {
  const dx = to.x - from.x;
  const dz = to.z - from.z;
  if (dx === 0 && dz === 0) return 0;
  return Math.atan2(dx, dz);
}

// 角度归一化:让旋转走“最短路径”,避免跨过 ±π 时突然转一整圈
function shortestAngleTo(from: number, to: number) {
  const delta = ((to - from + Math.PI) % (2 * Math.PI)) - Math.PI;
  return from + delta;
}

// 等待:用 Tween 做一个“纯计时动作”,统一由 tweenGroup 管理
// - 这样序列里的“停稳”也能跟动画同一套时间基准推进
function waitMs(ms: number) {
  const state = { t: 0 };
  return tweenTo(state, { t: 1 }, { durationMs: ms, easing: Easing.Linear.None }, () => void 0);
}

// AGV 沿路径移动(分段执行)
async function moveAgvAlongPath(points: Array<THREE.Vector3>) {
  if (!agv) return;
  if (points.length < 2) return;

  for (let i = 0; i < points.length - 1; i += 1) {
    const from = points[i];
    const to = points[i + 1];
    // 1) 本段时长:由距离/速度推导(工业化关键)
    const durationMs = computeMoveDurationMs(from, to);
    // 2) 平移 state:Tween 驱动的数据对象
    const state = { x: from.x, z: from.z };
    // 3) 航向 state:从当前 yaw 出发,旋到“指向下一路点”的 yaw
    const yawState = { y: agv.rotation.y };
    const targetYaw = shortestAngleTo(yawState.y, computeYawRad(from, to));

    // 平移与转向并行推进:更接近真实行驶(边走边对齐航向)
    await Promise.all([
      tweenTo(
        state,
        { x: to.x, z: to.z },
        { durationMs, easing: motionConfig.agvMoveEasing },
        () => {
          // onUpdate:写回位置(Y=0 贴地)
          agv?.position.set(state.x, 0, state.z);
        },
      ),
      tweenTo(
        yawState,
        { y: targetYaw },
        // 转向通常比位移稍快完成(0.7 倍时长并钳制),让“车头先对齐”
        { durationMs: clampMs(durationMs * 0.7, 300, 1200), easing: motionConfig.agvYawEasing },
        () => {
          if (!agv) return;
          agv.rotation.y = yawState.y;
        },
      ),
    ]);

    // 到达路点后“停稳”:模拟设备到位,便于后续机械臂动作/状态切换
    if (motionConfig.waypointDwellMs > 0 && i < points.length - 2) {
      await waitMs(motionConfig.waypointDwellMs);
    }
  }
}

// 机械臂关节旋转(单关节示例)
async function rotateJointY(deg: number) {
  if (!joint1) return;

  // rotation.y 是弧度,输入 deg 先转弧度
  const start = joint1.rotation.y;
  const target = start + THREE.MathUtils.degToRad(deg);
  const state = { y: start };

  await tweenTo(
    state,
    { y: target },
    { durationMs: 900, easing: motionConfig.jointYawEasing, delayMs: 150 },
    () => {
      if (!joint1) return;
      joint1.rotation.y = state.y;
    },
  );
}

// 工业化:每次触发动作前,先清空上一轮 Tween,并重置到可预测状态
function resetPose() {
  // 清空所有 Tween,防止按钮连点导致堆积/叠加
  tweenGroup.removeAll();
  agv?.position.set(0, 0, 0);
  if (agv) agv.rotation.y = 0;
  if (joint1) joint1.rotation.y = 0;
}

// 序列:先 AGV 走完路径,再机械臂旋转
async function runSequence() {
  resetPose();

  const path = [
    new THREE.Vector3(0, 0, 0),
    new THREE.Vector3(2.5, 0, 0),
    new THREE.Vector3(2.5, 0, 1.6),
    new THREE.Vector3(0.8, 0, 1.6),
  ];

  await moveAgvAlongPath(path);
  await rotateJointY(60);
}

// 并行:AGV 走路径的同时,机械臂也旋转(互不等待)
async function runParallel() {
  resetPose();

  const path = [
    new THREE.Vector3(0, 0, 0),
    new THREE.Vector3(2.2, 0, 0),
    new THREE.Vector3(2.2, 0, 1.2),
    new THREE.Vector3(0.5, 0, 1.2),
  ];

  await Promise.all([moveAgvAlongPath(path), rotateJointY(60)]);
}

onMounted(() => {
  const container = containerRef.value;
  if (!container) throw new Error('Three container not found');

  // ---------- 场景 ----------
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  // ---------- 渲染器 ----------
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  container.appendChild(renderer.domElement);

  // ---------- 相机 ----------
  camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
  camera.position.set(5, 4, 7);
  camera.lookAt(1, 0, 0.5);

  // ---------- 辅助物:坐标轴与网格 ----------
  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(10, 10));

  // ---------- 光照:让 StandardMaterial 正常显示 ----------
  const ambient = new THREE.AmbientLight(0xffffff, 0.7);
  const dir = new THREE.DirectionalLight(0xffffff, 1.0);
  dir.position.set(3, 5, 2);
  scene.add(ambient, dir);

  // ---------- AGV:用 Group 作为根节点,便于未来扩展为“车体+轮子+传感器”层级 ----------
  const agvGroup = new THREE.Group();
  agvBody = new THREE.Mesh(
    new THREE.BoxGeometry(0.8, 0.2, 0.6),
    new THREE.MeshStandardMaterial({ color: 0x22c55e, roughness: 0.6, metalness: 0.1 }),
  );
  agvBody.position.set(0, 0.1, 0);
  agvGroup.add(agvBody);
  agv = agvGroup;
  scene.add(agvGroup);

  // ---------- 机械臂底座 ----------
  const base = new THREE.Group();
  base.position.set(0.5, 0, 1.2);
  baseMesh = new THREE.Mesh(
    new THREE.CylinderGeometry(0.25, 0.25, 0.15, 24),
    new THREE.MeshStandardMaterial({ color: 0x93c5fd, roughness: 0.5, metalness: 0.2 }),
  );
  baseMesh.position.y = 0.075;
  base.add(baseMesh);
  armBase = base;
  scene.add(base);

  // ---------- 关节 1(绕 Y 轴旋转) ----------
  // 关节用 Group 表示:旋转时只需要改 group.rotation
  const j1 = new THREE.Group();
  j1.position.set(0, 0.15, 0);
  link1 = new THREE.Mesh(
    new THREE.BoxGeometry(0.6, 0.08, 0.12),
    new THREE.MeshStandardMaterial({ color: 0xfbbf24, roughness: 0.6, metalness: 0.1 }),
  );
  link1.position.set(0.3, 0.04, 0);
  j1.add(link1);
  base.add(j1);
  joint1 = j1;

  // ---------- 自适应 + 启动循环 ----------
  resize();
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  rafId = requestAnimationFrame(animate);
});

onBeforeUnmount(() => {
  // 1) 停止渲染循环与监听
  if (rafId !== null) cancelAnimationFrame(rafId);
  if (resizeObserver) resizeObserver.disconnect();

  // 2) 清空 Tween(防止未完成的 Tween 引用对象导致泄漏)
  tweenGroup.removeAll();

  // 3) 从场景移除根节点(解除场景引用)
  if (scene && agv) scene.remove(agv);
  if (scene && armBase) scene.remove(armBase);

  // 4) 释放几何体与材质(释放 GPU 资源)
  const disposeMesh = (mesh: THREE.Mesh | null) => {
    if (!mesh) return;
    mesh.geometry?.dispose();
    const material = mesh.material;
    if (Array.isArray(material)) material.forEach((m) => m.dispose());
    else material?.dispose();
  };

  disposeMesh(link1);
  disposeMesh(baseMesh);
  disposeMesh(agvBody);

  // 5) 释放渲染器并移除 canvas
  renderer?.dispose();
  if (renderer?.domElement && renderer.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  // 6) 断开引用,避免误用
  link1 = null;
  baseMesh = null;
  agvBody = null;
  joint1 = null;
  armBase = null;
  agv = null;
  camera = null;
  scene = null;
  renderer = null;
  resizeObserver = null;
  rafId = null;
});
</script>

<style scoped>
.three-container {
  width: 100%;
  height: 100vh;
  overflow: hidden;
  position: relative;
}

.hud {
  /* HUD 叠加层:用于触发动作与展示自检信息 */
  position: absolute;
  left: 12px;
  top: 12px;
  display: grid;
  gap: 8px;
  padding: 10px 12px;
  border-radius: 10px;
  background: rgba(2, 6, 23, 0.65);
  color: #e2e8f0;
  font-size: 14px;
  user-select: none;
}

.row {
  display: flex;
  gap: 8px;
  align-items: center;
}

.btn {
  padding: 6px 10px;
  border: 1px solid rgba(148, 163, 184, 0.4);
  border-radius: 8px;
  background: rgba(15, 23, 42, 0.7);
  color: #e2e8f0;
  cursor: pointer;
}

.btn:hover {
  border-color: rgba(148, 163, 184, 0.7);
}
</style>

解释(把“序列/并行”工程化落地):

建议用下面清单做“最低工程自测”(能快速定位 80% 的卡顿问题):

  1. 更新闭环是否正确:
  1. 帧率是否稳定:
  1. 每帧逻辑是否过重:
  1. 动画对象是否可控:
  1. 资源释放是否到位:

大模型任务(AI 协同指令模板 + 期望输出 + 校验点)

任务 1:AI 生成 Tween.js 基础动画代码

给 AI 的指令模板:

我在 Vue3 + TypeScript + Three.js 的组件中使用 Tween.js(@tweenjs/tween.js)。请生成一个最小可运行示例:在 requestAnimationFrame 中调用 tweenGroup.update(time),并实现一个 Mesh 从 (0,0,0) 移动到 (2,0,1) 的位移动画。要求代码包含依赖安装命令、Vue SFC 结构、以及每段代码的解释。

期望输出:

校验点:

任务 2:AI 推荐缓动函数(基于工业设备运动特点)

给 AI 的指令模板:

我需要模拟“电机驱动的 AGV 平移”和“机械臂关节旋转”。请给出两类动作分别推荐的缓动函数(从 Quadratic/Cubic/Quartic/Quintic/Sinusoidal/Exponential 中选择),并解释为什么(启停感、负载感、是否允许瞬时加速度变化)。同时给出推荐的时长范围(例如 0.6s/1.2s/2.0s 对应什么节奏)。

期望输出:

校验点:

任务 3:设计 AGV 与机械臂的基础动画方案

给 AI 的指令模板:

期望输出:

校验点:

课后作业

参考与延伸